Pro Entity Framework Core 2 for ASP.NET Core MVC 翻译

第 8 章 运动商店:扩展功能

作者:Adam Freeman
翻译:陈广
日期:2018-12-29


当您创建一个应用程序时,重点通常是获得正确的功能,这就是我对运动商店项目所采取的方法。随着应用程序的发展,增加您正在处理的数据量是非常有用的,这样您就可以看到它对用户必须执行的操作以及它们所花费的时间的影响。在本章中,我将测试数据添加到数据库中,以显示在将数据呈现给用户的方式中存在的缺陷,并通过添加对分页、排序和搜索数据的支持来解决这些缺陷。我还向您展示了如何通过使用 Entity Framework Core 来提高这些操作的性能,该内核支持高级数据模型配置选项,称为 Fluent API。

准备本章

我继续使用在第4章中创建并在此后的章节中更新的 SportsStore 项目。在 SportsStore 项目文件夹中运行清单8-1所示的命令,以删除和重新创建数据库。

提示:您可以从本书的 GitHub 存储库下载本章的 SportsStore 项目和其他章节的项目:https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc

清单 8-1:删除和重建数据库

dotnet ef database drop --force
dotnet ef database update

创建种子数据控制器和视图

本章,我需要一个控制器来向数据库填充测试数据。我向 Controllers 文件夹添加了一个名为 SeedController.cs 的文件,并使用它定义了清单8-2所示的控制器。

清单 8-2:Controllers 文件夹下的 SeedController.cs 文件的内容

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SportsStore.Models;
using System.Linq;

namespace SportsStore.Controllers
{

    public class SeedController : Controller
    {
        private DataContext context;

        public SeedController(DataContext ctx) => context = ctx;

        public IActionResult Index()
        {
            ViewBag.Count = context.Products.Count();
            return View(context.Products
                .Include(p => p.Category).OrderBy(p => p.Id).Take(20));
        }

        [HttpPost]
        public IActionResult CreateSeedData(int count)
        {
            ClearData();
            if (count > 0)
            {
                context.Database.SetCommandTimeout(System.TimeSpan.FromMinutes(10));
                context.Database
                    .ExecuteSqlCommand("DROP PROCEDURE IF EXISTS CreateSeedData");
                context.Database.ExecuteSqlCommand($@"
                    CREATE PROCEDURE CreateSeedData
	                    @RowCount decimal
                    AS
	                  BEGIN
	                  SET NOCOUNT ON
                      DECLARE @i INT = 1;
	                  DECLARE @catId BIGINT;
	                  DECLARE @CatCount INT = @RowCount / 10;
	                  DECLARE @pprice DECIMAL(5,2);
	                  DECLARE @rprice DECIMAL(5,2);
	                  BEGIN TRANSACTION
		                WHILE @i <= @CatCount
			              BEGIN
				            INSERT INTO Categories (Name, Description)
				            VALUES (CONCAT('Category-', @i), 
                                             'Test Data Category');
				            SET @catId = SCOPE_IDENTITY();
				            DECLARE @j INT = 1;
				            WHILE @j <= 10
					            BEGIN
						           SET @pprice = RAND()*(500-5+1);
						           SET @rprice = (RAND() * @pprice) 
                                       + @pprice;
						           INSERT INTO Products (Name, CategoryId, 
                                       PurchasePrice, RetailPrice)
						           VALUES (CONCAT('Product', @i, '-', @j), 
                                        @catId, @pprice, @rprice)
						           SET @j = @j + 1
					            END
		                    SET @i = @i + 1
		                    END
	                    COMMIT
                    END");
                context.Database.BeginTransaction();
                context.Database
                    .ExecuteSqlCommand($"EXEC CreateSeedData @RowCount = {count}");
                context.Database.CommitTransaction();
            }
            return RedirectToAction(nameof(Index));
        }

        [HttpPost]
        public IActionResult ClearData()
        {
            context.Database.SetCommandTimeout(System.TimeSpan.FromMinutes(10));
            context.Database.BeginTransaction();
            context.Database.ExecuteSqlCommand("DELETE FROM Orders");
            context.Database.ExecuteSqlCommand("DELETE FROM Categories");
            context.Database.CommitTransaction();
            return RedirectToAction(nameof(Index));
        }
    }
}

在生成大量测试数据时,创建 .NET 对象并将其存储在数据库中是效率低下的。Seed 控制器利用 Entity Framework Core 特性直接使用 SQL 创建和执行存储过程,该存储过程生成测试数据的速度要快得多(我在第23章中详细描述了这些特性)。


不要在实际项目中这样做

我在清单8-2中所使用的方法仅应用于生成测试数据,而不要在应用程序的任何其它部分使用。

对于本章,我需要一种机制,这样您就可以可靠地生成大量的测试数据,而无需复杂的数据库任务或使用第三方工具。(有一些优秀的商业工具可用于生成 SQL 数据,但对于几百行以上的数据通常需要许可。)

应该谨慎地直接使用 SQL,因为它绕过了 Entity Framework Core 提供的许多有用的保护,很难测试和维护,并且常常在单个数据库服务器上工作。您还应当避免在 C# 代码中创建存储过程,为了简单起见,我在清单8-2中这样做了。

总之,不要在应用程序的生产部分中使用这种技术。


为给控制器提供一个视图,我创建了 Views/Seed 文件夹,并向其添加了一个名为 Index.cshtml 的文件,内容如清单8-3所示。

清单 8-3:Views/Seed 文件夹下的 Index.cshtml 文件的内容

@model IEnumerable<Product>

<h3 class="p-2 bg-primary text-white text-center">Seed Data</h3>

<form method="post">
    <div class="form-group">
        <label>Number of Objects to Create</label>
        <input class="form-control" name="count" value="50" />
    </div>

    <div class="text-center">
        <button type="submit" asp-action="CreateSeedData" class="btn btn-primary">
            Seed Database
        </button>
        <button asp-action="ClearData" class="btn btn-danger">
            Clear Database
        </button>
    </div>
</form>

<h5 class="text-center m-2">
    There are @ViewBag.Count products in the database
</h5>

<div class="container-fluid">
    <div class="row">
        <div class="col-1 font-weight-bold">Id</div>
        <div class="col font-weight-bold">Name</div>
        <div class="col font-weight-bold">Category</div>
        <div class="col font-weight-bold text-right">Purchase</div>
        <div class="col font-weight-bold text-right">Retail</div>
    </div>
    @foreach (Product p in Model)
    {
        <div class="row">
            <div class="col-1">@p.Id</div>
            <div class="col">@p.Name</div>
            <div class="col">@p.Category.Name</div>
            <div class="col text-right">@p.PurchasePrice</div>
            <div class="col text-right">@p.RetailPrice</div>
        </div>
    }
</div>

视图允许您指定应该生成多少测试数据,并显示由 Seed 控制器的Index action 中的查询提供的前20个Product对象。为了使 Seed 控制器更容易使用,我将清单8-4所示的元素添加到共享布局中。

清单 8-4:Views/Shared 文件夹下的 _Layout.cshtml 文件,添加一个元素

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>SportsStore</title>
    <link rel="stylesheet" href="~/lib/twitter-bootstrap/css/bootstrap.min.css" />
    <style>
        .placeholder {
            visibility: collapse;
            display: none
        }

            .placeholder:only-child {
                visibility: visible;
                display: flex
            }
    </style>
</head>
<body>
    <div class="container-fluid">
        <div class="row p-2">
            <div class="col-2">
                <a asp-controller="Home" asp-action="Index"
                   class="@GetClassForButton("Home")">
                    Products
                </a>
                <a asp-controller="Categories" asp-action="Index"
                   class="@GetClassForButton("Categories")">
                    Categories
                </a>
                <a asp-controller="Orders" asp-action="Index"
                   class="@GetClassForButton("Orders")">
                    Orders
                </a>
                <a asp-controller="Seed" asp-action="Index"
                   class="@GetClassForButton("Seed")">
                    Seed Data
                </a>
            </div>
            <div class="col">
                @RenderBody()
            </div>
        </div>
    </div>
</body>
</html>

@functions {
string GetClassForButton(string controller)
    {
        return "btn btn-block " + (ViewContext.RouteData.Values["controller"]
        as string == controller ? "btn-primary" : "btn-outline-primary");
    }
}

使用dotnet run启动应用程序,并导航至 http://localhost:5000,并单击【Seed Data】按钮。将input元素的值设为1000,并单击【Seed Database】按钮。生成数据将花费一点时间,之后您将看到图8-1所示的结果。

**提示:测试数据的价格值是随机生成的,这意味着有些示例结果可能略有不同。

图8-1 运行示例应用程序

扩大数据显示

不需要大量的数据,SportsStore 应用程序显示其数据的方式上的缺陷就已显现。对于一千个对象,数据呈现给用户的方式变得不可用,对于应用程序来说,这仍然是一个相对较小的数据量。在接下来的部分中,我改变了 SportsStore 应用程序显示其数据的方式,以帮助用户执行基本操作并定位他们所需的对象。

添加分页支持

我要解决的第一个问题是分解提供给用户的数据,使其不仅仅是一个长列表。在布局应用程序的功能时,使用包含所有对象的简单表是一种有用的方法,但是包含数千行的表在大多数应用程序中是不可用的。为了解决这个问题,我将添加对从数据库查询较小数据量的支持,并允许用户通过分页浏览这些较小的数据量。

在处理大量数据时,必须确保对该数据的访问进行一致的管理,以便应用程序的一个部分无法意外地查询数百万个对象。我要采取的方法是创建一个包含分页的集合类。

创建分页集合类

为了定义提供对分页数据的访问的集合,我创建了 Models/Pages 文件夹,向其添加了一个名为 PagedList.cs 的文件,并使用它来定义清单8-5所示的类。

清单 8-5:Models/Pages 文件夹下的 PagedList.cs 文件的内容

using System.Collections.Generic;
using System.Linq;

namespace SportsStore.Models.Pages
{
    public class PagedList<T> : List<T>
    {
        public PagedList(IQueryable<T> query, QueryOptions options = null)
        {
            CurrentPage = options.CurrentPage;
            PageSize = options.PageSize;

            TotalPages = query.Count() / PageSize;
            AddRange(query.Skip((CurrentPage - 1) * PageSize).Take(PageSize));
        }

        public int CurrentPage { get; set; }
        public int PageSize { get; set; }
        public int TotalPages { get; set; }

        public bool HasPreviousPage => CurrentPage > 1;
        public bool HasNextPage => CurrentPage < TotalPages;
    }
}

我使用了一个强类型的List作为基类,这将使我可以轻松地构建基础集合行为。构造器接收一个IQueryable<T>,它表示将向用户提供要显示的数据的查询。这个查询将执行两次 —— 一次是为了获得查询可以返回的对象总数,另一次是获得将显示在当前页面上的对象。这是分页过程中固有的权衡,在这个过程中,额外的COUNT查询与针对较小数量对象的查询保持平衡。其他构造函数参数指定查询所需的页和每页要显示的对象数。

为了表示查询所需的选项,我在 Models/Pages 文件夹中添加了一个名为 QueryOptions.cs 的类文件,代码如清单8-6所示。

清单 8-6:Models/Pages 文件夹下的 QueryOptions.cs 文件的内容

namespace SportsStore.Models.Pages
{
    public class QueryOptions
    {
        public int CurrentPage { get; set; } = 1;
        public int PageSize { get; set; } = 10;
    }
}

更新存储库

为了确保一致地使用分页,我将返回一个PagedList对象,作为通过存储库执行的查询的结果。在清单8-7中,我添加了一个名为GetProduct的方法,它返回单个页面的数据。

清单 8-7:Models 文件夹下的 IRepository.cs 文件,返回整页数据

using System.Collections.Generic;
using SportsStore.Models.Pages;

namespace SportsStore.Models
{
    public interface IRepository
    {
        IEnumerable<Product> Products { get; }
        PagedList<Product> GetProducts(QueryOptions options);
        Product GetProduct(long key);
        void AddProduct(Product product);
        void UpdateProduct(Product product);
        void UpdateAll(Product[] products);
        void Delete(Product product);
    }
}

在清单8-8中,我对存储库的实现类进行了相应的更改。

清单 8-8:Models 文件夹下的 DataRepository.cs 文件,返回整页数据

using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using SportsStore.Models.Pages;

namespace SportsStore.Models
{
    public class DataRepository : IRepository
    {
        private DataContext context;
        public DataRepository(DataContext ctx) => context = ctx;
        public IEnumerable<Product> Products => context.Products
            .Include(p => p.Category).ToArray();

        public PagedList<Product> GetProducts(QueryOptions options)
        {
            return new PagedList<Product>(context.Products
                .Include(p => p.Category), options);
        }

        //...其余省略...

    }
}

新方法返回由参数指定的页的Product对象的PagedList集合。

更新控制器和视图

为了向 Home 控制器添加对分页的支持,我更新了Index action,以便它接受选择页面所需的参数,因此使用了新的存储库方法,如清单8-9所示。

清单 8-9:Controllers 文件夹下的 HomeController.cs 文件,使用分页数据

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using SportsStore.Models.Pages;

namespace SportsStore.Controllers
{
    public class HomeController : Controller
    {
        private IRepository repository;
        private ICategoryRepository catRepository;

        public HomeController(IRepository repo, ICategoryRepository catRepo)
        {
            repository = repo;
            catRepository = catRepo;
        }

        public IActionResult Index(QueryOptions options)
        {
            return View(repository.GetProducts(options));
        }

        public IActionResult UpdateProduct(long key)
        {
            ViewBag.Categories = catRepository.Categories;
            return View(key == 0 ? new Product() : repository.GetProduct(key));
        }

        [HttpPost]
        public IActionResult UpdateProduct(Product product)
        {
            if (product.Id == 0)
            {
                repository.AddProduct(product);
            }
            else
            {
                repository.UpdateProduct(product);
            }
            return RedirectToAction(nameof(Index));
        }
        [HttpPost]
        public IActionResult Delete(Product product)
        {
            repository.Delete(product);
            return RedirectToAction(nameof(Index));
        }
    }
}

分页数据集合的基类实现了IEnumerable<T>接口,该接口最小化了支持分页数据所需的更改范围。Home 控制器的Index action 的视图所需的唯一更改是显示一个分部视图,该视图将提供分页的详细信息,如清单8-10所示。视图的其余部分不需要更改,因为它将以相同的方式枚举数据,而不管它操作的序列是包含所有可用数据还是只包含其中的一页数据。

清单 8-10:Views/Home 文件夹下的 Index.cshtml 文件,使用分部视图

@model IEnumerable<Product>
<h3 class="p-2 bg-primary text-white text-center">Products</h3>

<div class="text-center">
    @Html.Partial("Pages", Model)
</div>

<div class="container-fluid mt-3">
    <div class="row">
        <div class="col-1 font-weight-bold">Id</div>
        <div class="col font-weight-bold">Name</div>
        <div class="col font-weight-bold">Category</div>
        <div class="col font-weight-bold text-right">Purchase Price</div>
        <div class="col font-weight-bold text-right">Retail Price</div>
        <div class="col"></div>
    </div>
    @foreach (Product p in Model)
    {
        <div class="row p-2">
            <div class="col-1">@p.Id</div>
            <div class="col">@p.Name</div>
            <div class="col">@p.Category.Name</div>
            <div class="col text-right">@p.PurchasePrice</div>
            <div class="col text-right">@p.RetailPrice</div>
            <div class="col">
                <form asp-action="Delete" method="post">
                    <a asp-action="UpdateProduct" asp-route-key="@p.Id"
                       class="btn btn-outline-primary">
                        Edit
                    </a>
                    <input type="hidden" name="Id" value="@p.Id" />
                    <button type="submit" class="btn btn-outline-danger">
                        Delete
                    </button>
                </form>
            </div>
        </div>
    }
    <div class="text-center p-2">
        <a asp-action="UpdateProduct" asp-route-key="0"
           class="btn btn-primary">Add</a>
    </div>
</div>

为完成对Product对象的分页支持,我通过在 Views/Shared 文件夹下添加了一个名为 Pages.cshtml 的文件来定义分部视图,并添加了如清单8-11所示的元素。

清单 8-11:Views/Shared 文件夹下的 Pages.cshtml 文件的内容

<form id="pageform" method="get" class="form-inline d-inline-block">
    <button name="options.currentPage" value="@(Model.CurrentPage -1)"
            class="btn btn-outline-primary @(!Model.HasPreviousPage ? "disabled" : "")"
            type="submit">
        Previous
    </button>
    @for (int i = 1; i <= 3 && i <= Model.TotalPages; i++)
    {
        <button name="options.currentPage" value="@i" type="submit"
                class="btn btn-outline-primary @(Model.CurrentPage == i ? "active" : "")">
            @i
        </button>
    }
    @if (Model.CurrentPage > 3 && Model.TotalPages - Model.CurrentPage >= 3)
    {
        @:...
        <button class="btn btn-outline-primary active">@Model.CurrentPage</button>
    }
    @if (Model.TotalPages > 3)
    {
        @:...
        @for (int i = Math.Max(4, Model.TotalPages - 2);
            i <= Model.TotalPages; i++)
        {
            <button name="options.currentPage" value="@i" type="submit"
                    class="btn btn-outline-primary
                    @(Model.CurrentPage == i ? "active" : "")">
                @i
            </button>
        }
    }
    <button name="options.currentPage" value="@(Model.CurrentPage +1)" type="submit"
            class="btn btn-outline-primary @(!Model.HasNextPage? "disabled" : "")">
        Next
    </button>
    <select name="options.pageSize" class="form-control ml-1 mr-1">
        @foreach (int val in new int[] { 10, 25, 50, 100 })
        {
            <option value="@val" selected="@(Model.PageSize == val)">@val</option>
        }
    </select>
    <input type="hidden" name="options.currentPage" value="1" />
    <button type="submit" class="btn btn-secondary">Change Page Size</button>
</form>

视图包含一个 HTML 表单,用于将 GET 请求发送回数据页的 action 方法,并更改页面大小。Razor 表达式看起来很混乱,但它们将显示给用户的分页按钮调整为可用的页数。若要查看效果,请使用dotnet run启动应用程序,并导航至 http://localhost:5000。产品列表将被分解为由10个项组成的页面,这些项可以使用一系列按钮进行分页,如图8-2所示。

图8-2 分页数据

添加对搜索和排序的支持

显示分页是一个好的开始,但聚焦于指定对象集合仍然困难。为了给用户定位数据所需的工具,我将在分页功能的基础上添加对更改显示顺序和执行搜索的支持。首先是展开PagedList类,以便它可以执行搜索,并使用属性名称(而不是选择属性的lambda表达式)对查询结果排序,如清单8-12所示。这需要一些复杂的代码来执行操作,但是结果可以应用于任何数据模型类,并且更容易与应用程序的 ASP.NET Core MVC 部分集成。

清单 8-12:Models/Pages 文件夹下的 PagedList.cs 文件,添加功能

using System.Collections.Generic;
using System.Linq;
using System;
using System.Linq.Expressions;
namespace SportsStore.Models.Pages
{
    public class PagedList<T> : List<T>
    {
        public PagedList(IQueryable<T> query, QueryOptions options = null)
        {
            CurrentPage = options.CurrentPage;
            PageSize = options.PageSize;
            Options = options;
            if (options != null)
            {
                if (!string.IsNullOrEmpty(options.OrderPropertyName))
                {
                    query = Order(query, options.OrderPropertyName,
                        options.DescendingOrder);
                }
                if (!string.IsNullOrEmpty(options.SearchPropertyName)
                    && !string.IsNullOrEmpty(options.SearchTerm))
                {
                    query = Search(query, options.SearchPropertyName,
                        options.SearchTerm);
                }
            }
            TotalPages = query.Count() / PageSize;
            AddRange(query.Skip((CurrentPage - 1) * PageSize).Take(PageSize));
        }

        public int CurrentPage { get; set; }
        public int PageSize { get; set; }
        public int TotalPages { get; set; }
        public QueryOptions Options { get; set; }
        public bool HasPreviousPage => CurrentPage > 1;
        public bool HasNextPage => CurrentPage < TotalPages;

        private static IQueryable<T> Search(IQueryable<T> query, string propertyName,
        string searchTerm)
        {
            var parameter = Expression.Parameter(typeof(T), "x");
            var source = propertyName.Split('.').Aggregate((Expression)parameter,
                Expression.Property);
            var body = Expression.Call(source, "Contains", Type.EmptyTypes,
                Expression.Constant(searchTerm, typeof(string)));
            var lambda = Expression.Lambda<Func<T, bool>>(body, parameter);
            return query.Where(lambda);
        }
        private static IQueryable<T> Order(IQueryable<T> query, string propertyName,
        bool desc)
        {
            var parameter = Expression.Parameter(typeof(T), "x");
            var source = propertyName.Split('.').Aggregate((Expression)parameter,
                Expression.Property);
            var lambda = Expression.Lambda(typeof(Func<,>).MakeGenericType(typeof(T),
                source.Type), source, parameter);
            return typeof(Queryable).GetMethods().Single(
                method => method.Name == (desc ? "OrderByDescending"
                : "OrderBy")
                    && method.IsGenericMethodDefinition
                    && method.GetGenericArguments().Length == 2
                    && method.GetParameters().Length == 2)
                    .MakeGenericMethod(typeof(T), source.Type)
                .Invoke(null, new object[] { query, lambda }) as IQueryable<T>;
        }
    }
}

清单8-13显示了QueryOptions类相应的改变。

清单 8-13:Models/Pages 文件夹下的 QueryOptions.cs 文件,添加属性

namespace SportsStore.Models.Pages
{
    public class QueryOptions
    {
        public int CurrentPage { get; set; } = 1;
        public int PageSize { get; set; } = 10;

        public string OrderPropertyName { get; set; }
        public bool DescendingOrder { get; set; }

        public string SearchPropertyName { get; set; }
        public string SearchTerm { get; set; }
    }
}

若要创建向用户显示搜索和排序选项的通用视图,我在 Views/Shared 文件夹下添加了一个名为 PageOptions.cshtml 的文件,并添加了清单8-14所示的内容**

清单 8-14:Views/Shared 文件夹下的 PageOptions.cshtml 文件的内容

<div class="container-fluid mt-2">
    <div class="row m-1">
        <div class="col"></div>
        <div class="col-1">
            <label class="col-form-label">Search</label>
        </div>
        <div class="col-3">
            <select form="pageform" name="options.searchpropertyname"
                    class="form-control">
                @foreach (string s in ViewBag.searches as string[])
                {
                    <option value="@s"
                            selected="@(Model.Options.SearchPropertyName == s)">
                        @(s.IndexOf('.') == -1 ? s : s.Substring(0, s.IndexOf('.')))
                    </option>
                }
            </select>
        </div>
        <div class="col">
            <input form="pageform" class="form-control" name="options.searchterm"
                   value="@Model.Options.SearchTerm" />
        </div>
        <div class="col-1 text-right">
            <button form="pageform" class="btn btn-secondary" type="submit">
                Search
            </button>
        </div>
        <div class="col"></div>
    </div>
    <div class="row m-1">
        <div class="col"></div>
        <div class="col-1">
            <label class="col-form-label">Sort</label>
        </div>
        <div class="col-3">
            <select form="pageform" name="options.OrderPropertyName"
                    class="form-control">
                @foreach (string s in ViewBag.sorts as string[])
                {
                    <option value="@s"
                            selected="@(Model.Options.OrderPropertyName == s)">
                        @(s.IndexOf('.') == -1 ? s : s.Substring(0, s.IndexOf('.')))
                    </option>
                }
            </select>
        </div>
        <div class="col form-check form-check-inline">
            <input form="pageform" type="checkbox" name="Options.DescendingOrder"
                   id="Options.DescendingOrder"
                   class="form-check-input" value="true"
                   checked="@Model.Options.DescendingOrder" />
            <label class="form-check-label">Descending Order</label>
        </div>
        <div class="col-1 text-right">
            <button form="pageform" class="btn btn-secondary" type="submit">
                Sort
            </button>
        </div>
        <div class="col"></div>
    </div>
</div>

此视图依赖于 HTML5 特性,即将元素与form元素之外的表单关联起来,这意味着我可以使用特定于搜索和排序的元素扩展 Pages 视图中定义的表单。

我不想硬编码用户可以在视图中用来搜索或排序数据的属性列表,因此,为了简单起见,我从ViewBag中获取这些值。这不是一个理想的解决方案,但它确实提供了很大的灵活性,它允许我很容易地使相同的内容适应不同的视图和不同的数据。为了将搜索和排序元素与Product列表一起显示给用户,我将清单8-15中所示的内容添加到 Home 控制器使用的 Index 视图中。

清单 8-15:Views/Home 文件夹下的 Index.cshtml 文件,显示产品功能

@model IEnumerable<Product>
<h3 class="p-2 bg-primary text-white text-center">Products</h3>

<div class="text-center">
    @await Html.PartialAsync("Pages", Model)
    @{
        ViewBag.searches = new string[] { "Name", "Category.Name" };
        ViewBag.sorts = new string[] { "Name", "Category.Name",
            "PurchasePrice", "RetailPrice"};
    }
    @await Html.PartialAsync("PageOptions", Model)
</div>

<div class="container-fluid mt-3">
   <!-- ...其它元素省略... -->
</div>

代码块指定用户将能够搜索和订购Product对象的Product属性,而@Html.Partial表达式则呈现这些功能对应的元素。

要查看结果,使用dotnet run启动应用程序,并导航至 http://localhost:5000。您将看到一系列新的元素,它们使得导航数据更加容易,如图8-3所示。

图8-3 搜索和排序产品对象

将数据表示功能应用于类别

将分页、搜索和排序功能放在适当位置的过程比较艰难,但是现在基础已经就绪,我可以将它们应用于应用程序中的其他数据类型,例如Category对象的管理。首先,我更新了存储库接口和实现类,以添加一个接受QueryOptions对象并返回PagedList结果的方法,如清单8-16所示。

清单 8-16:Models 文件夹下的 CategoryRepository.cs 文件,添加分页支持

using System.Collections.Generic;
using SportsStore.Models.Pages;

namespace SportsStore.Models
{
    public interface ICategoryRepository
    {
        IEnumerable<Category> Categories { get; }
        PagedList<Category> GetCategories(QueryOptions options);
        void AddCategory(Category category);
        void UpdateCategory(Category category);
        void DeleteCategory(Category category);
    }
    public class CategoryRepository : ICategoryRepository
    {
        private DataContext context;
        public CategoryRepository(DataContext ctx) => context = ctx;
        public IEnumerable<Category> Categories => context.Categories;

        public PagedList<Category> GetCategories(QueryOptions options)
        {
            return new PagedList<Category>(context.Categories, options);
        }
        public void AddCategory(Category category)
        {
            context.Categories.Add(category);
            context.SaveChanges();
        }
        public void UpdateCategory(Category category)
        {
            context.Categories.Update(category);
            context.SaveChanges();
        }
        public void DeleteCategory(Category category)
        {
            context.Categories.Remove(category);
            context.SaveChanges();
        }
    }
}

在清单8-17中,我在管理Category对象的控制器的Index action 方法中添加了一个QueryOptions参数,并使用它查询存储库。

清单 8-17:Controllers 文件夹下的 CategoriesController.cs 文件,添加分页支持

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using SportsStore.Models.Pages;

namespace SportsStore.Controllers
{
    public class CategoriesController : Controller
    {
        private ICategoryRepository repository;
        public CategoriesController(ICategoryRepository repo) => repository = repo;

        public IActionResult Index(QueryOptions options)
            => View(repository.GetCategories(options));

        [HttpPost]
        public IActionResult AddCategory(Category category)
        {
            repository.AddCategory(category);
            return RedirectToAction(nameof(Index));
        }
        public IActionResult EditCategory(long id)
        {
            ViewBag.EditId = id;
            return View("Index", repository.Categories);
        }
        [HttpPost]
        public IActionResult UpdateCategory(Category category)
        {
            repository.UpdateCategory(category);
            return RedirectToAction(nameof(Index));
        }
        [HttpPost]
        public IActionResult DeleteCategory(Category category)
        {
            repository.DeleteCategory(category);
            return RedirectToAction(nameof(Index));
        }
    }
}

最后,通过将清单8-18中所示的元素添加到 Categories 控制器使用的 Index 视图中,我可以向用户展示这些特性。

清单 8-18:Views/Categories 文件夹下的 Index.cshtml 文件,添加功能

@model IEnumerable<Category>

<h3 class="p-2 bg-primary text-white text-center">Categories</h3>

<div class="text-center">
    @await Html.PartialAsync("Pages", Model)
    @{
        ViewBag.searches = new string[] { "Name", "Description" };
        ViewBag.sorts = new string[] { "Name", "Description" };
    }
    @await Html.PartialAsync("PageOptions", Model)
</div>

<div class="container-fluid mt-3">
    <!-- ...其它元素省略... -->
</div>

为查看新功能,启动应用程序,导航至 http://localhost:5000,并单击【Categories】按钮。类别列表在页面中显示,用户可以对它们按需要进行搜索和排序,如图8-4所示。

图8-4 分页排序和搜索类别

数据库索引

向数据库添加1000个测试对象足以让数据显示方式的扩展限制显现,但不足以显示数据库的局限性。为了了解处理更多数据的效果,我将语句添加到PagedList构造函数中,该构造函数度量执行查询和将运行时间写入控制台所需的时间,如清单8-19所示。

提示:有许多方法来衡量性能,大多数数据库服务器都提供了一些工具,可以帮助您理解执行查询所需的时间。在 SQL Server 中,SQl Server profiler 和 SQl Server Management Studio 工具提供了大量的详细信息。这些工具可能很有用,但我通常依赖于清单8-19中所采用的方法,因为它简单而准确,足以理解任何性能问题的严重性。

清单 8-19:Models/Pages 文件夹下的 PagedList.cs 文件,查询计时

using System.Collections.Generic;
using System.Linq;
using System;
using System.Linq.Expressions;
using System.Diagnostics;

namespace SportsStore.Models.Pages
{
    public class PagedList<T> : List<T>
    {
        public PagedList(IQueryable<T> query, QueryOptions options = null)
        {
            CurrentPage = options.CurrentPage;
            PageSize = options.PageSize;
            Options = options;
            if (options != null)
            {
                if (!string.IsNullOrEmpty(options.OrderPropertyName))
                {
                    query = Order(query, options.OrderPropertyName,
                        options.DescendingOrder);
                }
                if (!string.IsNullOrEmpty(options.SearchPropertyName)
                    && !string.IsNullOrEmpty(options.SearchTerm))
                {
                    query = Search(query, options.SearchPropertyName,
                        options.SearchTerm);
                }
            }

            Stopwatch sw = Stopwatch.StartNew();
            Console.Clear();

            TotalPages = query.Count() / PageSize;
            AddRange(query.Skip((CurrentPage - 1) * PageSize).Take(PageSize));

            Console.WriteLine($"Query Time: {sw.ElapsedMilliseconds} ms");
        }

        // ...其它省略...
    }
}

使用dotnet run启动应用程序,并导航至 http://localhost:5000,单击【Seed Data】按钮,并使用要测试的对象数填充数据库。当数据库被种子化后,单击【Products】按钮,为【Sort】 select元素选择【Purchase Price】属性,选择【Descending Order】选项,然后单击【Sort】按钮。

如果您检查应用程序生成的日志消息,将看到用于获取数据的查询及其花费的时间。

...
SELECT COUNT(*)
FROM [Products] AS [p]
...
SELECT [p].[Id], [p].[CategoryId], [p].[Name], [p].[PurchasePrice],
[p].[RetailPrice], [p.Category].[Id], [p.Category].[Description],
[p.Category].[Name
FROM [Products] AS [p]
INNER JOIN [Categories] AS [p.Category] ON [p].[CategoryId] = [p.Category].[Id]
ORDER BY [p].[PurchasePrice] DESC
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
...
Query Time: 14 ms
...

表8-1显示了在我的开发机器上对不同数量的种子数据执行这些查询所需的时间。在执行测试时,您可能会看到不同的时间,但重要的是执行查询所需的时间随着数据量的增加而增加。

表 8-1:执行查询所花费的时间

对象 耗时
1,000 14ms
10,000 17ms
100,000 185ms
1,000,000 2453ms
2,000,000 5713ms

创建和应用索引

部分性能问题是,数据库服务器必须检查大量数据行才能找到应用程序所需的数据。减少数据库服务器必须执行的工作量的一种有效方法是创建索引,这将加快查询速度,但在准备一些初始时间之后,在每次更新之后进行一些额外的工作。对于 SportsStore 应用程序,我将为用户可以用来搜索或订购数据的ProductCategory类的属性添加索引。索引是在数据库 context 类中创建的,如清单8-20所示。

清单 8-20:Models 文件夹下的 DataContext.cs 文件,创建索引

using Microsoft.EntityFrameworkCore;

namespace SportsStore.Models
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> opts) : base(opts) { }
        public DbSet<Product> Products { get; set; }
        public DbSet<Category> Categories { get; set; }
        public DbSet<Order> Orders { get; set; }
        public DbSet<OrderLine> OrderLines { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>().HasIndex(p => p.Name);
            modelBuilder.Entity<Product>().HasIndex(p => p.PurchasePrice);
            modelBuilder.Entity<Product>().HasIndex(p => p.RetailPrice);
            modelBuilder.Entity<Category>().HasIndex(p => p.Name);
            modelBuilder.Entity<Category>().HasIndex(p => p.Description);
        }
    }
}

OnModelCreating方法被重写以使用 Entity Framework Core Fluent API 特性自定义数据模型,我在本书第2和第3部分中对此进行了详细描述。Fluent API 允许您覆盖默认的 Entity Framework Core 行为并访问高级功能,例如创建索引。在清单中,我为Produce类的NamePurchasePriceRetailPrice属性,以及Category类的NameDescription属性创建了索引。我不需要为主键或外键属性创建索引,因为 Entity Framework Core 默认为我创建索引。

创建索引需要创建新的迁移并应用于数据库。在 SportsStore 项目文件夹中运行清单8-21所示的命令,以创建名为 Indexes 的迁移,并将其应用于数据库。

提示:当数据库中有大量数据时,应用创建索引的迁移可能需要一段时间,因为所有现有数据都必须添加到索引中。在执行迁移命令之前,您可能希望使用种子控制器来减少测试数据量。

清单 8-21:创建和应用数据库迁移

dotnet ef migrations add Indexes
dotnet ef database update

一旦应用了迁移,重新启动应用程序并重复查询测试以查看对性能的影响。表8-2显示了为我的PC添加索引之前和之后的查询时间。

表 8-2:执行查询所花费的时间

对象 耗时 索引耗时
1,000 14ms 9ms
10,000 17ms 10ms
100,000 185ms 23 ms
1,000,000 2453ms 143ms
2,000,000 5713ms 158

了解 COUNT 查询性能 随着数据量的增加,所需时间仍略有增加。我为获取存储在数据库中的对象数量而进行的查询被转换为 SQL SELECT COUNT命令,对于大量对象,该命令的性能下降。数据库服务器通常提供其他对数据进行计数的方法,在 SQL Server中,您可以查询数据库服务器维护的有关数据库的元数据,如下所示:

...
select sum (spart.rows)
from sys.partitions spart
where spart.object_id = object_id('Products') and spart.index_id < 2
...

无法使用 LINQ 执行这种类型的查询。相反,有关使用支持直接执行 SQL 命令的 Entity Framework Core 功能的详细信息,请参阅第23章。


常见问题及解决方法

要扩展应用程序所支持的数据量,就需要谨慎地调整应用程序的 ASP.NET Core MVC 部分,要求 Entity Framework Core 获取更少的数据量,并提供用于排序和搜索数据的工具。在接下来的部分中,我将描述您最可能遇到的问题,并解释如何解决这些问题。

对页面的查询速度太慢

查询缓慢的最可能原因是,应用程序从数据库中检索所有对象,然后在内存中排序或搜索它们,然后只获取单个页面所需的对象。每次用户切换到新页面时,都会重复此过程,从而创建大量的工作来检索和处理,然后丢弃对象。

这个问题通常是由在IEnumerable<T>接口上调用 LINQ 方法引起的,而不是像第5章所描述的IQueryable<T>接口。诊断此问题的最快方法是查看应用程序的日志消息,以查看 Entity Framework Core 生成的 SQL 查询。尽管细节会有所不同,但使用带有ORDER BYSkip LINQ 方法的IQueryable<T>接口将产生带有ORDER BYOFFSET子句的查询。

如果您使用的是IQueryable<T>接口,那么应该检查重复的查询,如第5章所述。很容易忘记,枚举对象序列将触发查询,例如,当计算出需要多少页按钮时。

应用索引迁移超时

将迁移应用到添加索引的数据库时,数据库服务器必须使用已经存储的数据填充索引。对于大型数据库来说,这可能是一个很长的过程,dotnet ef命令可以在进程完成之前超时,这将导致迁移失败并阻止创建索引。为了解决开发中的这个问题,删除并重新创建数据库,以便在没有数据时应用索引。对于生产系统,备份数据库,删除数据,然后应用迁移。一旦创建了索引,就可以使用小块数据再次填充数据库,这样每次更新只需要少量的工作。

创建索引并未提高性能

如果发现索引没有提高查询时间,那么首先要检查的是创建索引的迁移是否已应用于数据库。下一个最可能的问题是,没有为应用程序用于查询的所有属性创建索引。如果应用程序使用属性组合进行搜索,则可能需要创建其他索引,如第3部分所述。

总结

本章展示了使用 SportsStore 应用程序来处理更大数量的数据。我增加了对分页、排序和数据搜索的支持,这允许用户一次处理可管理的多个对象。我还使用 Fluent API 定制数据模型,并添加索引以提高查询性能。在下一章中,我将面向客户的功能添加到 SportsStore 应用程序中。

;

© 2018 - IOT小分队文章发布系统 v0.3